Skip to content

fix(runtime): one exit of N no longer disables try/catch for the process (#739 item 2) - #762

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/739-exit-request-per-thread
Jul 29, 2026
Merged

fix(runtime): one exit of N no longer disables try/catch for the process (#739 item 2)#762
InauguralPhysicist merged 1 commit into
mainfrom
fix/739-exit-request-per-thread

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Second of #739's items. Item 1 (the trace tape) landed in #761; this is the one flagged as most severe of the remainder.

The bug

exit is deliberately uncatchable: builtin_exit sets g_exit_requested, and CHECK_ERROR consults it to refuse routing the unwind to a try handler. But the flag was a process global that nothing ever reset — not eigs_close, not eigs_state_destroy, and main leaves it set on purpose. So once any script in any state called exit, every later eigs_eval_string in the process ran with exception handling silently disabled: a raise inside try went to vm_error_halt instead of the catch handler.

Reproduced through the embed API — unreachable from the CLI, where exit ends the process, which is why it had never been seen:

before exit -> CAUGHT
after  exit -> (null)        <- catch handler never fires again

For a long-lived host running untrusted snippets — the sandbox_run case, and the seam EigenOS M11 consumes — one line of script permanently corrupted the semantics.

The fix

g_exit_requested/g_exit_code move onto EigsThread, beside the g_has_error/g_try_depth that CHECK_ERROR reads them with — they were the only members of that flag family still process-global. The request is cleared at host eval entry, using the idiom repl.c:67 already had for g_has_error ("don't carry a prior line's error into this one"). It applies to the eval that raised it and no later one. exit stays uncatchable.

The exit code is additionally latched on EigsState. Per-thread alone silently dropped exit of N inside a spawned worker to 0 while main returned success — a regression this change introduced, which the suite had no coverage for. The thread flag drives the uncatchable unwind; the state latch decides the process's status. Side benefit: a worker's exit no longer erases a genuine main-thread error.

Two reader-side fixes fall out, both the same root cause — a bridge macro is not a process global

  • main.c's REPL path read the flag after eigs_thread_detach(), leaving nothing to read it through; the REPL returned 0 instead of the requested code. It captures before teardown now, as the script path already did. Every other read was audited — all are on attached threads.
  • eigenscript.h's comment still said "Process-global (exit terminates the whole process)" directly above the new note contradicting it. Merged into one block.

Validation

Both new checks were validated red before being trusted:

check red without the fix
test_exit.sh spawned-worker case rc=0 want=9
embed_smoke try/catch-after-exit FAIL … (#739)

The embed check ships a before-exit control that passes in both runs, so the check isolates the right thing rather than the harness.

Behavior verified unchanged: exit of 7 → 7, exit inside try → 3 (still uncatchable), REPL exit of 5 → 5, worker exit of 9 → 9.

  • release 3274/3274
  • asan+ubsan 3278/3278, asan-http 3374/3374 — leak tally 0 in both
  • freestanding symbol gate stage 1+2 OK; embed smoke OK; exit suite 6/6

Refs #739 — items 3 (g_task_suspend_request global while its scheduler is per-thread) and 4 (s_osr_threshold, g_sandbox_*, g_stream_file, g_model, g_db_conn) remain open.

🤖 Generated with Claude Code

…ocess (#739 item 2)

`exit` is deliberately uncatchable: builtin_exit sets g_exit_requested and
CHECK_ERROR consults it to refuse routing the unwind to a `try` handler.
But the flag was a PROCESS GLOBAL that nothing ever reset — not
eigs_close, not eigs_state_destroy, and main leaves it set on purpose. So
once any script in any state called `exit`, every later eigs_eval_string
in the process ran with exception handling silently disabled: a raise
inside `try` went to vm_error_halt instead of the catch handler.

Reproduced through the embed API (unreachable from the CLI, where `exit`
ends the process — which is why this had never been seen):

    before exit -> CAUGHT
    after  exit -> (null)        <- catch handler never fires again

For a long-lived host running untrusted snippets — the sandbox_run case,
and the seam EigenOS M11 consumes — one line of script permanently
corrupted the semantics.

The request now lives on EigsThread beside the g_has_error / g_try_depth
CHECK_ERROR reads it with (they were the only members of that flag family
still process-global), and is cleared at host eval entry using the idiom
repl.c already had for g_has_error. It applies to the eval that raised it
and no later one. `exit` stays uncatchable.

The exit CODE is additionally latched on EigsState. Per-thread alone
silently dropped `exit of N` inside a SPAWNED WORKER to 0 while main
returned success — a regression this change introduced and the suite had
no coverage for. The thread flag drives the uncatchable unwind; the state
latch decides the process's status. A side benefit: a worker's exit no
longer erases a genuine main-thread error.

Two reader-side fixes fall out of the move, both the same root cause —
a bridge macro is not a process global:

- main.c's REPL path read the flag AFTER eigs_thread_detach, so the REPL
  returned 0 instead of the requested code. It captures before teardown
  now, like the script path already did. Every other read was audited;
  all are on attached threads.
- eigenscript.h's comment still said "Process-global (exit terminates the
  whole process)" directly above the new note contradicting it. Merged.

Tests: tests/test_exit.sh gains the spawned-worker case (validated red:
rc=0 want=9), and embed_smoke gains the try/catch-after-exit case with a
before-exit control (validated red; the control passes in both runs, so
the check isolates the right thing).

Gates: release 3274/3274; asan+ubsan 3278/3278 and asan-http 3374/3374,
leak tally 0 in both; freestanding stage 1+2 OK; embed smoke OK; exit
suite 6/6. Verified unchanged: `exit of 7` -> 7, uncatchable in `try` ->
3, REPL `exit of 5` -> 5, worker `exit of 9` -> 9.

Refs #739

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 05:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an embedding-specific runtime bug where calling exit once could permanently disable try/catch handling for subsequent evaluations in the same process, by moving exit request state off process globals and scoping/resetting it correctly per thread/eval while still preserving process exit status behavior.

Changes:

  • Move g_exit_requested/g_exit_code onto EigsThread and add an EigsState latch for the process exit code; clear per-thread exit request at embed eval entry.
  • Update CLI/REPL exit-code reporting to read the state latch before eigs_thread_detach().
  • Add regression coverage (spawned worker exit code; embed smoke for try/catch-after-exit) and update docs + changelog.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_exit.sh Adds regression test ensuring exit of N inside a spawned worker still determines the process exit code.
src/main.c Captures latched exit code before thread teardown in REPL path; switches script-mode exit status to state latch.
src/embed_smoke.c Adds embed repro/regression check: try/catch must keep working after an eval that called exit.
src/eigs_embed.c Clears per-thread exit request at eigs_eval_string entry to prevent cross-eval semantic corruption.
src/eigenscript.h Adds per-thread exit fields + state latch fields and bridge macros; updates comments accordingly.
src/builtins.c Updates builtin_exit to set per-thread request and state-level latch for process exit status.
docs/EMBEDDING.md Documents that exit is per-eval/per-thread for embedding and no longer poisons later evals.
docs/BUILTINS.md Clarifies exit behavior under embedding (doesn’t disable later try/catch).
CHANGELOG.md Records the bug and fix details under “Fixed”.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/EMBEDDING.md
later one. Before that it was a process global nothing ever reset: one
untrusted snippet calling `exit` left every subsequent eval in the
process running with exception handling silently disabled, in any state.
A host that wants the exit code reads it from the eval that requested it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants